winbrew_app\operations\remove/
plan.rs

1//! Removal planning and dependency analysis.
2//!
3//! The planning phase reads the package database, resolves the package to
4//! remove, and collects any installed packages that still depend on it. The
5//! resulting [`RemovalPlan`] is a snapshot of what the execution phase should
6//! remove, not a live view of the database.
7//!
8//! This split matters because the CLI wants to inspect the plan before it
9//! mutates anything. It can display dependents, ask for confirmation, and only
10//! then hand the plan to the execution layer.
11
12use crate::database;
13use crate::models::domains::install::RemovalPlan;
14use crate::models::domains::installed::InstalledPackage;
15
16use super::Result;
17
18/// Find installed packages that depend on the named package.
19///
20/// Dependency entries may include a version suffix in the form `name@version`.
21/// Only the package name is used for matching so the dependency check remains
22/// stable even if the dependency recorded a specific version.
23pub fn find_dependents(name: &str, conn: &database::DbConnection) -> Result<Vec<String>> {
24    let mut dependents = database::list_packages(conn)?
25        .into_iter()
26        .filter(|pkg| {
27            pkg.name != name
28                && pkg
29                    .dependencies
30                    .iter()
31                    .any(|dep| dependency_name(dep).eq_ignore_ascii_case(name))
32        })
33        .map(|pkg| pkg.name)
34        .collect::<Vec<_>>();
35
36    dependents.sort_unstable();
37    dependents.dedup();
38
39    Ok(dependents)
40}
41
42/// Build a removal plan for the named package.
43///
44/// The function loads the package record, gathers current dependents, and then
45/// returns a single immutable plan that can be inspected by the caller before
46/// removal starts. If the package does not exist, the database error is
47/// preserved so the CLI can report that the target package was not found.
48pub fn plan_removal(name: &str) -> Result<RemovalPlan> {
49    let conn = database::get_conn()?;
50    let pkg = database::get_package(&conn, name)?.ok_or_else(|| {
51        anyhow::Error::new(database::PackageNotFoundError {
52            name: name.to_string(),
53        })
54    })?;
55    let dependents = find_dependents(name, &conn)?;
56
57    Ok(removal_plan(pkg, dependents))
58}
59
60/// Construct a removal plan from an already loaded package and dependent list.
61///
62/// This helper keeps the public planning API focused on database access while
63/// still making the plan shape easy to test in isolation.
64fn removal_plan(pkg: InstalledPackage, dependents: Vec<String>) -> RemovalPlan {
65    RemovalPlan {
66        package: pkg,
67        dependents,
68    }
69}
70
71/// Extract the dependency package name from a stored dependency string.
72///
73/// Dependency records may carry a version suffix after `@`; only the package
74/// name participates in removal dependency matching.
75fn dependency_name(dep: &str) -> &str {
76    dep.split_once('@').map_or(dep, |(name, _)| name)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::removal_plan;
82    use crate::models::domains::install::EngineMetadata;
83    use crate::models::domains::install::InstallScope;
84    use crate::models::domains::install::InstallerType;
85    use crate::models::domains::installed::{InstalledPackage, PackageStatus};
86
87    fn package(
88        name: &str,
89        kind: InstallerType,
90        install_dir: &str,
91        engine_metadata: Option<EngineMetadata>,
92    ) -> InstalledPackage {
93        InstalledPackage {
94            name: name.to_string(),
95            version: "1.0.0".to_string(),
96            kind,
97            deployment_kind: kind.deployment_kind(),
98            engine_kind: kind.into(),
99            engine_metadata,
100            install_dir: install_dir.to_string(),
101            dependencies: Vec::new(),
102            status: PackageStatus::Ok,
103            installed_at: "2026-04-05T00:00:00Z".to_string(),
104        }
105    }
106
107    #[test]
108    fn removal_plan_preserves_engine_metadata() {
109        let plan = removal_plan(
110            package(
111                "Contoso.App",
112                InstallerType::Msix,
113                r"C:\Packages\Contoso.App",
114                Some(EngineMetadata::msix(
115                    "Contoso.App_1.0.0_x64__8wekyb3d8bbwe",
116                    InstallScope::Installed,
117                )),
118            ),
119            vec!["Contoso.Consumer".to_string()],
120        );
121
122        assert_eq!(plan.package.name, "Contoso.App");
123        assert_eq!(
124            plan.package.engine_metadata,
125            Some(EngineMetadata::msix(
126                "Contoso.App_1.0.0_x64__8wekyb3d8bbwe",
127                InstallScope::Installed,
128            ))
129        );
130        assert_eq!(plan.dependents, vec!["Contoso.Consumer".to_string()]);
131    }
132}